前一章已經找出目標系統的安全邊界與風險情境。其中一項常見問題,是程式在沒有確認內容的情況下,直接使用來自邊界外的資料。只要欄位缺漏、型別不同或內容超出預期,後續計算、狀態變更與輸出就可能產生錯誤。
資料來源看似可靠,也不代表每次取得的內容都符合目前程式。格式版本可能改變,既有紀錄可能包含歷史例外,執行設定也可能輸入錯誤。目標系統需要在資料進入主要處理前,先確認它符合本次功能接受的結構與規則。
本章會先界定來源不明資料的邊界,區分靜態型別檢查與執行時期驗證(Runtime Validation),並說明如何先將尚未驗證的資料視為未知內容,再用資料結構描述(Schema)確認欄位、型別、格式與範圍。接著比較多種型別檢查工具的定義方式、驗證能力、適用情境與限制。如果目標系統具有應用程式介面(Application Programming Interface,API),也會比較不同 API 合約工具如何共用輸入、輸出與錯誤定義。
資料通過驗證後,還要明確轉換成內部資料模型。如果目標系統採用物件關聯對映(Object-Relational Mapping,ORM)與關聯式資料庫,入口驗證、功能規則、保存模型及完整性限制要各自負責不同層次的保護。本章最後會說明驗證失敗與測試方式,確認無效資料無法繞過邊界進入後續流程。
來源不明的資料,是指程式尚未確認其實際內容是否符合目前功能規格的值。判斷重點在於資料是否已經通過本次處理所需的檢查,不能只按照它來自系統外部或內部分類。
常見邊界包括:
如果系統具有表單、應用程式介面(Application Programming Interface,API)、訊息佇列或其他輸入方式,就要分別記錄各邊界的提供者、格式版本、必要欄位、大小限制與失敗結果。相同資料經過不同入口時,也要確認是否採用相同規則,避免其中一條路徑繞過檢查。
完成驗證的值只代表符合某一份資料結構描述。資料經過保存、轉換或傳送後,如果信任條件已經改變,下一個邊界仍要按照自己的契約重新檢查。
TypeScript 的靜態型別檢查會在開發與建置期間檢查程式如何使用資料,但型別標註不會在執行時期自動檢查收到的值。程式即使宣告 ImportRow,實際輸入仍可能缺少欄位,或把數值寫成字串。
型別斷言也不會產生驗證。下列程式只是要求 TypeScript 將 raw 視為 ImportRow,執行時期的內容不會因此改變:
type ImportRow = {
code: string
amount: number
}
function calculateAmount(raw: unknown): number {
const row = raw as ImportRow
return row.amount * 2
}
如果 raw 實際為 { amount: "5" },這段程式不會先拒絕輸入。JavaScript 的運算規則甚至可能暫時產生看似可用的結果,讓錯誤延後到其他步驟才被發現。
來源不明的值應該先標示為 unknown。unknown 允許程式接收任何值,但在縮小型別或完成驗證前,不能直接讀取屬性。相較之下,any 會略過多數型別檢查,錯誤內容容易在處理流程中繼續傳遞。TypeScript 文件對 unknown 與 any 的說明也指出,unknown 需要先經過型別縮小,才可以當成更具體的型別使用。
typeof、Array.isArray 與自訂型別守衛適合檢查少量且簡單的條件。當物件包含必要欄位、長度、範圍、列舉值與巢狀結構時,逐項手寫判斷容易遺漏,也不容易讓多個入口共用相同規則。這時可以使用資料結構描述集中定義接受條件。
資料結構描述要表達「這項功能接受甚麼」,內容不能只重現程式語言中的型別。除了 string、number 或陣列等基本型別,還要按照功能規格定義下列條件:
| 檢查類型 | 需要確認的內容 |
|---|---|
| 欄位存在狀態 | 區分必要、選用、可為空值及可以省略的欄位。 |
| 基本型別 | 確認字串、數值、布林值、陣列與物件沒有被其他型別取代。 |
| 格式與大小 | 限制字串格式、字元數、陣列項目數與巢狀層數。 |
| 數值範圍 | 確認整數、小數精度、最小值、最大值及是否接受非有限數值。 |
| 可接受值 | 使用列舉或可辨識聯集限制狀態、種類及對應欄位。 |
| 未知欄位 | 明確決定拒絕、移除或保留沒有定義的欄位。 |
| 欄位關係 | 確認開始與結束、種類與內容等跨欄位組合符合規則。 |
每一項限制都要能連回功能規格、格式契約或已確認的風險。把所有字串一律限制為相同長度,或拒絕所有未知欄位,不一定符合每個入口的相容需求。規則過鬆會讓錯誤內容繼續流入系統,規則過嚴則可能拒絕原本有效的輸入。
驗證時也要區分「檢查」與「轉換」。例如,來源字串 "12" 是否能轉成數值 12,要由該入口的契約決定。未經確認就自動轉型,可能把格式錯誤隱藏成有效內容。需要正規化(Normalization)時,應該明確定義空白、大小寫、日期、代碼與預設值的處理方式,並且測試轉換前後結果。
TypeScript 專案可以使用資料結構描述函式庫進行執行時期型別驗證。這類工具和 tsc 的靜態型別檢查責任不同,它們會實際解析 unknown 資料,並產生已驗證結果或問題資訊。
| 工具 | 定義與驗證方式 | 適合情境 | 選用前要確認 |
|---|---|---|---|
| Zod | 以鏈式 API 定義資料結構描述,使用 parse 或 safeParse 驗證,並以 z.infer 推導型別。 |
專案需要成熟的 TypeScript 整合,而且相關框架已經直接支援 Zod。 | 確認使用的 Zod 版本、轉換行為、錯誤格式及整合套件的相容範圍。 |
| Valibot | 將資料結構描述、驗證與轉換拆成小型函式,透過 pipe 組合,並分別推導輸入與輸出型別。 |
專案重視模組化組合,而且需要控制實際匯入的程式大小。 | 確認整合工具使用標準資料結構描述介面(Standard Schema)或 Valibot 原生介面,以及兩者可設定的錯誤行為。 |
| ArkType | 使用接近 TypeScript 的字串或物件語法定義型別,呼叫產生的型別即可取得有效資料或結構化問題。 | 專案希望用接近 TypeScript 的方式表達聯集、巢狀結構及執行時期限制。 | 如果需要產生 JSON Schema 或 OpenAPI,先確認所用的 ArkType 功能是否具有可對應表示方式。 |
| TypeBox | 直接建立可推導 TypeScript 型別的 JSON Schema,並交由內建編譯器或相容的 JSON Schema 驗證器執行檢查。 | 系統以 JSON Schema 作為交換、文件或跨語言工具的共同格式。 | 確認採用的驗證器、JSON Schema 版本、格式檢查與額外欄位處理方式一致。 |
四種工具都能協助建立執行時期邊界,不能自行決定功能接受哪些資料。選擇時要比較資料結構描述的可讀性、錯誤格式、轉換能力、執行成品大小、驗證效能,以及現有框架與規格工具的整合方式。
以下以 Zod 示範資料結構描述、驗證結果與 TypeScript 型別如何共用。safeParse 會以可辨識聯集回傳成功資料或驗證問題,呼叫端不需要使用 try...catch 判斷一般輸入錯誤。
下列資料結構描述會檢查匯入內容的必要欄位、字串長度、數值範圍與狀態,也會拒絕沒有定義的欄位:
import * as z from 'zod'
const ImportRowSchema = z.strictObject({
code: z.string().trim().min(1).max(20),
amount: z.number().nonnegative(),
status: z.enum(['pending', 'confirmed']),
})
type ImportRowDto = z.infer<typeof ImportRowSchema>
function inspectImportRow(input: unknown) {
const result = ImportRowSchema.safeParse(input)
if (!result.success) {
return {
accepted: false as const,
issues: result.error.issues.map(issue => ({
path: issue.path.map(String).join('.'),
code: issue.code,
})),
}
}
return {
accepted: true as const,
value: result.data,
}
}
成功分支中的 result.data 才是 ImportRowDto。code 也已按照資料結構描述移除前後空白。呼叫端不需要再次使用型別斷言,也不能繞過失敗分支直接取得資料。
這個例子只示範一項輸入邊界。實際採用時,還要確認函式庫版本、非同步規則、錯誤格式與效能是否符合目標技術組合。Zod 的基本用法文件分別說明 parse、safeParse 與非同步解析的行為,選擇方法時要按照驗證失敗是否屬於預期分支,以及資料結構描述是否包含非同步檢查決定。
資料結構描述不應該成為所有功能規則的集合。欄位型別、格式與組合條件適合在邊界檢查。需要讀取目前狀態、比較其他資料或決定功能結果的規則,則應該留在內部處理流程,避免驗證層同時承擔資料存取與狀態變更。
如果目標系統已經確認使用 TypeScript API,可以使用 API 合約工具集中描述路徑或程序、輸入、輸出與錯誤。合約工具負責讓實作端與呼叫端共用定義,執行時期是否真的檢查內容,仍取決於合約是否連接資料結構描述驗證。
| 工具 | 合約來源 | 驗證與規格能力 | 適合情境與限制 |
|---|---|---|---|
| oRPC | 使用程序或獨立合約定義輸入、輸出與錯誤,再由路由組合功能。 | 支援標準資料結構描述介面的驗證器,也能整合 OpenAPI 規格與處理方式。 | 適合需要 TypeScript 程序呼叫與 OpenAPI 相容方式並存的系統。導入前要確認程序、路由與錯誤模型符合目標架構。 |
| tRPC | 從路由與程序推導合約,再將路由型別提供給官方呼叫端。 | 程序可以定義輸入與輸出驗證器,並且不需要先產生呼叫程式碼。 | 適合實作端與呼叫端都使用 TypeScript 並能共用路由型別的情境。如果需要提供獨立版本或跨語言規格,還要建立對應的發布方式。 |
| ts-rest | 先以路徑、HTTP 方法、狀態碼、標頭、輸入與輸出建立獨立合約,再分別實作與呼叫。 | 支援標準資料結構描述介面的驗證器,也能選擇產生 OpenAPI 規格。 | 適合保留 HTTP/REST 語意,並希望合約和特定實作框架分開管理的系統。 |
| Hono RPC | 從鏈式路由、驗證器與處理函式的回傳結果推導 AppType,再由 Hono Client 使用。 |
可搭配 Hono Validator 或第三方資料結構描述工具執行輸入驗證。 | 適合已經採用 Hono 且希望直接沿用路由型別的系統。路由需要維持可推導結構,規模增大時也要檢查型別推導對開發工具效能的影響。 |
四種工具的合約來源不同。oRPC 與 tRPC 以程序和路由為中心,ts-rest 先建立 HTTP 合約,Hono RPC 則直接從框架路由推導型別。選擇前要先確認 API 是否需要 OpenAPI、是否有非 TypeScript 呼叫端、合約是否要獨立發布,以及輸入與輸出是否都要執行時期驗證。
以下以 oRPC 示範如何在程序中共用輸入與輸出資料結構描述。oRPC 支援 Zod、Valibot、ArkType 與其他符合標準資料結構描述介面的函式庫,能在程序上定義輸入與輸出驗證。
下列程序沿用前一節的 ImportRowSchema,讓處理函式收到的 input 已經完成執行時期驗證:
import { os } from '@orpc/server'
import * as z from 'zod'
const ImportRowSchema = z.strictObject({
code: z.string().trim().min(1).max(20),
amount: z.number().nonnegative(),
status: z.enum(['pending', 'confirmed']),
})
export const inspectImport = os
.input(ImportRowSchema)
.output(z.object({
code: z.string(),
accepted: z.boolean(),
}))
.handler(({ input }) => ({
code: input.code,
accepted: true,
}))
oRPC 的程序文件說明 .input 與 .output 可以執行資料結構描述驗證。這能減少呼叫契約與實作型別分開維護造成的差異,但仍要自行定義正確的欄位規則、錯誤結果與內部模型。API 以外的檔案、訊息、執行設定或遺留資料入口,也不會因為採用 oRPC 就自動受到保護。
呼叫端的驗證可以提早顯示問題,處理端仍然要保留自己的邊界檢查。系統可能還有其他輸入來源,呼叫端程式也可能使用不同版本。只有處理端能決定這次執行是否接受內容,不能把資料完整性依賴在呼叫端已經檢查。
驗證成功後,資料已經符合入口契約,但不代表它應該直接成為核心處理或保存使用的結構。不同階段的模型具有不同責任:
| 模型 | 主要責任 | 不應該承擔的責任 |
|---|---|---|
| 邊界資料或資料傳輸物件(Data Transfer Object,DTO) | 表達某個入口可接受與可回傳的欄位。 | 不應該直接決定內部所有狀態與保存欄位。 |
| 內部資料模型(Internal Data Model) | 表達功能規則需要的概念、狀態與不變條件。 | 不應該隨每個輸入格式或保存方式直接變動。 |
| 保存模型或 ORM 實體 | 表達資料如何對映至已確認的保存結構。 | 不應該作為來源不明資料的第一道驗證。 |
邊界資料通過驗證後,可以透過明確函式轉換成內部模型。轉換過程只接收已驗證型別,並且補上由目標系統產生的狀態:
type ImportRowDto = {
code: string
amount: number
status: 'pending' | 'confirmed'
}
type ImportCommand = {
referenceCode: string
amount: number
confirmed: boolean
}
function toImportCommand(input: ImportRowDto): ImportCommand {
return {
referenceCode: input.code,
amount: input.amount,
confirmed: input.status === 'confirmed',
}
}
明確轉換可以防止來源額外帶入內部欄位,也讓欄位改名、狀態轉換與預設值具有固定位置。如果不同入口使用不同格式,它們可以各自驗證並轉換成相同內部模型,核心規則不需要理解每種外部表示方式。
如果目標系統已經確認使用關聯式資料庫與 ORM,可以在保存模型中定義欄位型別、是否允許空值、唯一性、關聯與索引,並按照實際需求使用資料庫提供的完整性限制。這些限制能防止不符合保存規則的狀態寫入,但不能取代入口驗證。
兩者的分工如下:
不能因為 ORM 實體已經宣告型別,就直接把來源物件傳入建立或更新方法。ORM 可能只在寫入時回報錯誤,也可能接受程式語言層級可以表示、卻不符合功能規則的值。大量賦值或自動欄位對映還可能讓來源修改原本不該開放的保存欄位。
保存結構改變時,應該使用 ORM 或資料庫工具提供的遷移(Migration)機制管理版本,並在部署前確認程式模型與實際結構相容。遷移負責改變保存結構,不會自動修正既有內容的功能意義。需要轉換歷史資料時,仍要定義來源條件、轉換規則、錯誤紀錄與驗證方式。
驗證失敗是可預期的功能結果,不應該和未預期的系統例外混在一起。每個入口要定義失敗後是否拒絕整批內容、只拒絕特定項目,或允許修正後重新提供。
錯誤結果應該包含足以定位問題的資訊,例如欄位路徑、規則代碼與可以理解的說明。內部函式庫名稱、呼叫堆疊與保存結構不需要提供給輸入來源。不同入口也可以使用適合自身形式的結果,例如表單對應欄位、檔案對應列號,或批次處理產生拒絕項目清單。
記錄驗證失敗時,只保存分析問題所需的類型、欄位與識別資訊。不要把密碼、權杖、完整個人資料或整份原始輸入寫入執行紀錄。後續章節會再說明錯誤分類、結構化記錄與集中處理方式,本章只需要確保無效資料不會繼續進入主要流程。
驗證也要設定處理上限。即使每個欄位型別都正確,過長字串、過多陣列項目或過深巢狀結構仍可能占用超出預期的處理資源。大小與複雜度限制要按照實際功能需求決定,並在昂貴的解析與處理前盡早套用。
驗證測試要從資料結構描述與風險情境建立,不能只測試一筆正常資料。每個重要入口至少要確認:
如果同一份資料結構描述由多個入口共用,要分別測試每個入口是否確實呼叫驗證。只測試資料結構描述本身,仍可能漏掉某條路徑直接使用原始值。系統具有保存機制時,也要加入整合測試,確認保存限制會拒絕繞過一般入口的不完整狀態。
遺留資料轉換可以使用固定案例記錄已知例外,再確認每一類資料會被接受、轉換、隔離或拒絕。不能為了讓遷移成功就整體放寬目標系統入口規則,否則歷史例外會成為日後所有輸入都能使用的格式。
完成一項輸入邊界後,可以使用下列問題確認結果:
unknown 或等同的安全型別接收?any 直接相信輸入內容?